<!doctype html>
<html lang="en">
    <head>
        <meta charset="UTF-8">
        <meta http-equiv="X-UA-Compatible" content="IE=edge">
        <meta name="viewport" content="width=device-width, initial-scale=1.0">
        <meta name="author" content="Katie Bell, Adam Hartz">
        <meta name="description" content="Simple REPL for Python WASM">
        <title>wasm-python terminal</title>
        <link
            rel="stylesheet"
            href="https://unpkg.com/xterm@4.18.0/css/xterm.css"
            crossorigin
            integrity="sha384-4eEEn/eZgVHkElpKAzzPx/Kow/dTSgFk1BNe+uHdjHa+NkZJDh5Vqkq31+y7Eycd"
        >
        <style>
            body {
                font-family: arial;
                max-width: 800px;
                margin: 0 auto;
            }
            #editor {
                padding: 5px;
                border: 1px solid black;
                width: 100%;
                height: 300px;
            }
            #info {
                padding-top: 20px;
            }
            .error {
              border: 1px solid red;
              background-color: #ffd9d9;
              padding: 5px;
              margin-top: 20px;
            }
            .button-container {
                display: flex;
                justify-content: end;
                height: 50px;
                align-items: center;
                gap: 10px;
            }
            button {
                padding: 6px 18px;
            }
        </style>
        <script
            src="https://unpkg.com/xterm@4.18.0/lib/xterm.js"
            crossorigin
            integrity="sha384-yYdNmem1ioP5Onm7RpXutin5A8TimLheLNQ6tnMi01/ZpxXdAwIm2t4fJMx1Djs+"
        ></script>
        <script
            src="https://cdnjs.cloudflare.com/ajax/libs/ace/1.43.1/ace.js"
            crossorigin
            integrity="sha512-kmA5vhcxOkZI0ReiKJMGNb8/KKbgbExIlnt6aXuPtl86AgHBEi6OHHOz2wsTazBDGZKxe7fmiE+pIuZJQks4+A=="
        ></script>
        <script type="module">
            const _magic_ctrlc_string = "__WASM_REPL_CTRLC_" + (Date.now()) + "__";
            class WorkerManager {
                constructor(
                    workerURL,
                    standardIO,
                    readyCallBack,
                    finishedCallback,
                ) {
                    this.workerURL = workerURL;
                    this.worker = null;
                    this.standardIO = standardIO;
                    this.readyCallBack = readyCallBack;
                    this.finishedCallback = finishedCallback;

                    this.initialiseWorker();
                }

                async initialiseWorker() {
                    if (!this.worker) {
                        this.worker = new Worker(this.workerURL, {
                            type: "module",
                        });
                        this.worker.addEventListener(
                            "message",
                            this.handleMessageFromWorker,
                        );
                    }
                }

                async run(options) {
                    this.worker.postMessage({
                        type: "run",
                        args: options.args || [],
                        files: options.files || {},
                    });
                }

                reset() {
                    if (this.worker) {
                        this.worker.terminate();
                        this.worker = null;
                    }
                    this.standardIO.message("Worker process terminated.");
                    this.initialiseWorker();
                }

                handleStdinData(inputValue) {
                    if (this.stdinbuffer && this.stdinbufferInt) {
                        let startingIndex = 1;
                        if (this.stdinbufferInt[0] > 0) {
                            startingIndex = this.stdinbufferInt[0];
                        }
                        const data = new TextEncoder().encode(inputValue);
                        data.forEach((value, index) => {
                            this.stdinbufferInt[startingIndex + index] = value;
                        });

                        this.stdinbufferInt[0] =
                            startingIndex + data.length - 1;
                        Atomics.notify(this.stdinbufferInt, 0, 1);
                    }
                }

                handleMessageFromWorker = (event) => {
                    const type = event.data.type;
                    if (type === "ready") {
                        this.readyCallBack();
                    } else if (type === "stdout") {
                        this.standardIO.stdout(event.data.stdout);
                    } else if (type === "stderr") {
                        this.standardIO.stderr(event.data.stderr);
                    } else if (type === "stdin") {
                        // Leave it to the terminal to decide whether to chunk it into lines
                        // or send characters depending on the use case.
                        this.stdinbuffer = event.data.buffer;
                        this.stdinbufferInt = new Int32Array(this.stdinbuffer);
                        this.standardIO.stdin().then((inputValue) => {
                            this.handleStdinData(inputValue);
                        });
                    } else if (type === "finished") {
                        this.standardIO.message(
                            `Exited with status: ${event.data.returnCode}`,
                        );
                        this.finishedCallback();
                    }
                };
            }

            class WasmTerminal {
                constructor() {
                    try {
                        this.history = JSON.parse(sessionStorage.getItem('__python_wasm_repl.history'));
                        this.historyBuffer = this.history.slice();
                    } catch(e) {
                        this.history = [];
                        this.historyBuffer = [];
                    }
                    this.reset();

                    this.xterm = new Terminal({
                        scrollback: 10000,
                        fontSize: 14,
                        theme: { background: "#1a1c1f" },
                        cols: 100,
                    });

                    this.xterm.onKey((keyEvent) => {
                        // Fix for iOS Keyboard Jumping on space
                        if (keyEvent.key === " ") {
                            keyEvent.domEvent.preventDefault();
                        }
                    });

                    this.xterm.onData(this.handleTermData);
                }

                reset() {
                    this.inputBuffer = new BufferQueue();
                    this.input = "";
                    this.resolveInput = null;
                    this.activeInput = false;
                    this.inputStartCursor = null;

                    this.cursorPosition = 0;
                    this.historyIndex = -1;
                    this.beforeHistoryNav = "";
                }

                open(container) {
                    this.xterm.open(container);
                }

                handleTermData = (data) => {
                    const ord = data.charCodeAt(0);
                    data = data.replace(/\r(?!\n)/g, "\n"); // Convert lone CRs to LF

                    // Handle pasted data
                    if (data.length > 1 && data.includes("\n")) {
                        let alreadyWrittenChars = 0;
                        // If line already had data on it, merge pasted data with it
                        if (this.input != "") {
                            this.inputBuffer.addData(this.input);
                            alreadyWrittenChars = this.input.length;
                            this.input = "";
                        }
                        this.inputBuffer.addData(data);
                        // If input is active, write the first line
                        if (this.activeInput) {
                            let line = this.inputBuffer.nextLine();
                            this.writeLine(line.slice(alreadyWrittenChars));
                            this.resolveInput(line);
                            this.activeInput = false;
                        }
                        // When input isn't active, add to line buffer
                    } else if (!this.activeInput) {
                        // Skip non-printable characters
                        if (!(ord === 0x1b || ord == 0x7f || ord < 32)) {
                            this.inputBuffer.addData(data);
                        }
                        // TODO: Handle more escape sequences?
                    } else if (ord === 0x1b) {
                        // Handle special characters
                        switch (data.slice(1)) {
                            case "[A": // up
                                this.historyBack();
                                break;
                            case "[B": // down
                                this.historyForward();
                                break;
                            case "[C": // right
                                this.cursorRight();
                                break;
                            case "[D": // left
                                this.cursorLeft();
                                break;
                            case "[H": // home key
                                this.cursorHome(true);
                                break;
                            case "[F": // end key
                                this.cursorEnd(true);
                                break;
                            case "[3~": // delete key
                                this.deleteAtCursor();
                                break;
                            default:
                                break;
                        }
                    } else if (ord < 32 || ord === 0x7f) {
                        switch (data) {
                            case "\x0c": // CTRL+L
                                this.clear();
                                break;
                            case "\n": // ENTER
                            case "\x0a": // CTRL+J
                            case "\x0d": // CTRL+M
                                this.resolveInput(
                                    this.input + this.writeLine("\n"),
                                );
                                this.input = "";
                                this.cursorPosition = 0;
                                this.activeInput = false;
                                break;
                            case "\x03": // CTRL+C
                                this.input = "";
                                this.cursorPosition = 0;
                                this.historyIndex = -1;
                                this.resolveInput(_magic_ctrlc_string + "\n");
                                break;
                            case "\x09": // TAB
                                this.handleTab();
                                break;
                            case "\x7F": // BACKSPACE
                            case "\x08": // CTRL+H
                                this.handleCursorErase(true);
                                break;
                            case "\x04": // CTRL+D
                                // Send empty input
                                if (this.input === "") {
                                    this.resolveInput("");
                                    this.cursorPosition = 0;
                                    this.activeInput = false;
                                }
                        }
                    } else {
                        this.handleCursorInsert(data);
                        this.updateHistory();
                    }
                };

                clearLine() {
                    this.xterm.write("\x1b[K");
                }

                writeLine(line) {
                    this.xterm.write(line.slice(0, -1));
                    this.xterm.write("\r\n");
                    return line;
                }

                handleCursorInsert(data) {
                    const trailing = this.input.slice(this.cursorPosition);
                    this.input =
                        this.input.slice(0, this.cursorPosition) +
                        data +
                        trailing;
                    this.cursorPosition += data.length;
                    this.xterm.write(data);
                    if (trailing.length !== 0) {
                        this.xterm.write(trailing);
                        this.xterm.write("\x1b[" + trailing.length + "D");
                    }
                    this.updateHistory();
                }

                handleTab() {
                    // handle tabs: from the current position, add spaces until
                    // this.cursorPosition is a multiple of 4.
                    const prefix = this.input.slice(0, this.cursorPosition);
                    const suffix = this.input.slice(this.cursorPosition);
                    const count = 4 - (this.cursorPosition % 4);
                    const toAdd = " ".repeat(count);
                    this.input = prefix + toAdd + suffix;
                    this.cursorHome(false);
                    this.clearLine();
                    this.xterm.write(this.input);
                    if (suffix) {
                        this.xterm.write("\x1b[" + suffix.length + "D");
                    }
                    this.cursorPosition += count;
                    this.updateHistory();
                }

                handleCursorErase() {
                    // Don't delete past the start of input
                    if (
                        this.xterm.buffer.active.cursorX <=
                        this.inputStartCursor
                    ) {
                        return;
                    }
                    const trailing = this.input.slice(this.cursorPosition);
                    this.input =
                        this.input.slice(0, this.cursorPosition - 1) + trailing;
                    this.cursorLeft();
                    this.clearLine();
                    if (trailing.length !== 0) {
                        this.xterm.write(trailing);
                        this.xterm.write("\x1b[" + trailing.length + "D");
                    }
                    this.updateHistory();
                }

                deleteAtCursor() {
                    if (this.cursorPosition < this.input.length) {
                        const trailing = this.input.slice(
                            this.cursorPosition + 1,
                        );
                        this.input =
                            this.input.slice(0, this.cursorPosition) + trailing;
                        this.clearLine();
                        if (trailing.length !== 0) {
                            this.xterm.write(trailing);
                            this.xterm.write("\x1b[" + trailing.length + "D");
                        }
                        this.updateHistory();
                    }
                }

                cursorRight() {
                    if (this.cursorPosition < this.input.length) {
                        this.cursorPosition += 1;
                        this.xterm.write("\x1b[C");
                    }
                }

                cursorLeft() {
                    if (this.cursorPosition > 0) {
                        this.cursorPosition -= 1;
                        this.xterm.write("\x1b[D");
                    }
                }

                cursorHome(updatePosition) {
                    if (this.cursorPosition > 0) {
                        this.xterm.write("\x1b[" + this.cursorPosition + "D");
                        if (updatePosition) {
                            this.cursorPosition = 0;
                        }
                    }
                }

                cursorEnd() {
                    if (this.cursorPosition < this.input.length) {
                        this.xterm.write(
                            "\x1b[" +
                                (this.input.length - this.cursorPosition) +
                                "C",
                        );
                        this.cursorPosition = this.input.length;
                    }
                }

                updateHistory() {
                    if (this.historyIndex !== -1) {
                        this.historyBuffer[this.historyIndex] = this.input;
                    } else {
                        this.beforeHistoryNav = this.input;
                    }
                }

                historyBack() {
                    if (this.history.length === 0) {
                        return;
                    } else if (this.historyIndex === -1) {
                        // we're not currently navigating the history; store
                        // the current command and then look at the end of our
                        // history buffer
                        this.beforeHistoryNav = this.input;
                        this.historyIndex = this.history.length - 1;
                    } else if (this.historyIndex > 0) {
                        this.historyIndex -= 1;
                    }
                    this.input = this.historyBuffer[this.historyIndex];
                    this.cursorHome(false);
                    this.clearLine();
                    this.xterm.write(this.input);
                    this.cursorPosition = this.input.length;
                }

                historyForward() {
                    if (this.history.length === 0 || this.historyIndex === -1) {
                        // we're not currently navigating the history; NOP.
                        return;
                    } else if (this.historyIndex < this.history.length - 1) {
                        this.historyIndex += 1;
                        this.input = this.historyBuffer[this.historyIndex];
                    } else if (this.historyIndex == this.history.length - 1) {
                        // we're coming back from the last history value; reset
                        // the input to whatever it was when we started going
                        // through the history
                        this.input = this.beforeHistoryNav;
                        this.historyIndex = -1;
                    }
                    this.cursorHome(false);
                    this.clearLine();
                    this.xterm.write(this.input);
                    this.cursorPosition = this.input.length;
                }

                prompt = async () => {
                    this.activeInput = true;
                    // Hack to allow stdout/stderr to finish before we figure out where input starts
                    setTimeout(() => {
                        this.inputStartCursor =
                            this.xterm.buffer.active.cursorX;
                    }, 1);
                    // If line buffer has a line ready, send it immediately
                    if (this.inputBuffer.hasLineReady()) {
                        return new Promise((resolve, reject) => {
                            resolve(
                                this.writeLine(this.inputBuffer.nextLine()),
                            );
                            this.activeInput = false;
                        });
                        // If line buffer has an incomplete line, use it for the active line
                    } else if (this.inputBuffer.lastLineIsIncomplete()) {
                        // Hack to ensure cursor input start doesn't end up after user input
                        setTimeout(() => {
                            this.handleCursorInsert(
                                this.inputBuffer.nextLine()
                            );
                        }, 1);
                    }
                    return new Promise((resolve, reject) => {
                        this.resolveInput = (value) => {
                            if (
                                value.replace(/\s/g, "").length != 0 &&
                                value != _magic_ctrlc_string + "\n"
                            ) {
                                if (this.historyIndex !== -1) {
                                    this.historyBuffer[this.historyIndex] =
                                        this.history[this.historyIndex];
                                }
                                this.history.push(value.slice(0, -1));
                                this.historyBuffer.push(value.slice(0, -1));
                                this.historyIndex = -1;
                                this.cursorPosition = 0;
                                try {
                                    sessionStorage.setItem('__python_wasm_repl.history', JSON.stringify(this.history));
                                } catch(e) {
                                }
                            }
                            resolve(value);
                        };
                    });
                };

                clear() {
                    this.xterm.clear();
                }

                print(charCode) {
                    let array = [charCode];
                    if (charCode == 10) {
                        array = [13, 10]; // Replace \n with \r\n
                    }
                    this.xterm.write(new Uint8Array(array));
                }
            }

            class BufferQueue {
                constructor(xterm) {
                    this.buffer = [];
                }

                isEmpty() {
                    return this.buffer.length == 0;
                }

                lastLineIsIncomplete() {
                    return (
                        !this.isEmpty() &&
                        !this.buffer[this.buffer.length - 1].endsWith("\n")
                    );
                }

                hasLineReady() {
                    return !this.isEmpty() && this.buffer[0].endsWith("\n");
                }

                addData(data) {
                    let lines = data.match(/.*(\n|$)/g);
                    if (this.lastLineIsIncomplete()) {
                        this.buffer[this.buffer.length - 1] += lines.shift();
                    }
                    for (let line of lines) {
                        this.buffer.push(line);
                    }
                }

                nextLine() {
                    return this.buffer.shift();
                }
            }

            const runButton = document.getElementById("run");
            const replButton = document.getElementById("repl");
            const stopButton = document.getElementById("stop");
            const clearButton = document.getElementById("clear");

            window.onload = () => {
                const terminal = new WasmTerminal();
                terminal.open(document.getElementById("terminal"));

                const stdio = {
                    stdout: (charCode) => {
                        terminal.print(charCode);
                    },
                    stderr: (charCode) => {
                        terminal.print(charCode);
                    },
                    stdin: async () => {
                        return await terminal.prompt();
                    },
                    message: (text) => {
                        terminal.writeLine(`\r\n${text}\r\n`);
                    },
                };

                const programRunning = (isRunning) => {
                    if (isRunning) {
                        replButton.setAttribute("disabled", true);
                        runButton.setAttribute("disabled", true);
                        stopButton.removeAttribute("disabled");
                    } else {
                        replButton.removeAttribute("disabled");
                        runButton.removeAttribute("disabled");
                        stopButton.setAttribute("disabled", true);
                    }
                };

                runButton.addEventListener("click", (e) => {
                    terminal.clear();
                    terminal.reset(); // reset the history
                    programRunning(true);
                    const code = editor.getValue();
                    pythonWorkerManager.run({
                        args: ["main.py"],
                        files: { "main.py": code },
                    });
                });

                replButton.addEventListener("click", (e) => {
                    terminal.clear();
                    terminal.reset(); // reset the history
                    const REPL = `
class WASMREPLKeyboardInterrupt(KeyboardInterrupt):
    pass

import sys
import code
import builtins

def _interrupt_aware_input(prompt=''):
    line = builtins.input(prompt)
    if line.strip() == "${_magic_ctrlc_string}":
        raise KeyboardInterrupt()
    return line

cprt = 'Type "help", "copyright", "credits" or "license" for more information.'
banner = f'Python {sys.version} on {sys.platform}\\n{cprt}'

code.interact(banner=banner, readfunc=_interrupt_aware_input, exitmsg='')
`;
                    programRunning(true);
                    pythonWorkerManager.run({ args: ["-c", REPL], files: {} });
                });

                stopButton.addEventListener("click", (e) => {
                    programRunning(false);
                    pythonWorkerManager.reset();
                });

                clearButton.addEventListener("click", (e) => {
                    terminal.clear();
                });

                const readyCallback = () => {
                    replButton.removeAttribute("disabled");
                    runButton.removeAttribute("disabled");
                    clearButton.removeAttribute("disabled");
                };

                const finishedCallback = () => {
                    programRunning(false);
                    pythonWorkerManager.reset();
                };

                const pythonWorkerManager = new WorkerManager(
                    "./python.worker.mjs",
                    stdio,
                    readyCallback,
                    finishedCallback,
                );
            };
            var editor;
            document.addEventListener("DOMContentLoaded", () => {
                editor = ace.edit("editor");
                editor.session.setMode("ace/mode/python");
            });
        </script>
    </head>
    <body>
        <div id="repldemo">
            <h1>Simple REPL for Python WASM</h1>
            <div id="editor">print('Welcome to WASM!')</div>
            <div class="button-container">
                <button id="run" disabled>Run code</button>
                <button id="repl" disabled>Start REPL</button>
                <button id="stop" disabled>Stop</button>
                <button id="clear" disabled>Clear</button>
            </div>
            <div id="terminal"></div>
            <div id="info">
                The simple REPL provides a limited Python experience in the
                browser.
                <a
                    href="https://github.com/python/cpython/blob/main/Tools/wasm/README.md"
                >
                    Tools/wasm/README.md
                </a>
                contains a list of known limitations and issues. Networking,
                subprocesses, and threading are not available.
            </div>
        </div>
        <div id="buffererror" class="error" style="display: none">
            <p>
                <code>SharedArrayBuffer</code>, which is required for this demo,
                is not available in your browser environment.  One common cause
                of this failure is loading <code>index.html</code> directly in
                your browser instead of using <code>server.py</code> as
                described in
                <a
                    href="https://github.com/python/cpython/blob/main/Tools/wasm/README.md#the-web-example"
                >
                    Tools/wasm/README.md
                </a>.
            </p>
            <p>
                For more details about security requirements for
                <code>SharedArrayBuffer</code>, see
                <a
                    href="https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/SharedArrayBuffer#security_requirements"
                  >this MDN page</a
                >.
            </p>
            <script>
              if (typeof SharedArrayBuffer === 'undefined') {
                  document.getElementById('repldemo').style.display = 'none';
                  document.getElementById('buffererror').style.display = 'block';
              }
            </script>
        </div>
    </body>
</html>
